Skip to content

Fix activity audit trail not recording board mutations#581

Merged
Chris0Jeky merged 6 commits intomainfrom
fix/521-activity-audit-trail
Mar 29, 2026
Merged

Fix activity audit trail not recording board mutations#581
Chris0Jeky merged 6 commits intomainfrom
fix/521-activity-audit-trail

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Fixes UX: Activity view audit trail not recording board mutations #521
  • Backend: CardService, ColumnService, BoardService, and LabelService now record audit log entries via IHistoryService.LogActionAsync after every successful mutation (create, update, move, delete, archive). Previously only update-conflict events were logged, causing the Activity view to show "No board activity yet" for boards with confirmed mutations.
  • DI: Register IHistoryService interface so mutation services receive the HistoryService via constructor injection. The dependency is optional (null-safe) to preserve backward compatibility.
  • Frontend: Activity view board selector now sorts non-archived boards first, ordered by updatedAt descending, so the default selection is the most-recently-active non-archived board instead of the first alphabetical board (which could be archived).

Test plan

  • 12 unit tests for audit event recording across card CRUD+move, column CRUD, board CRUD+archive, label CRUD
  • Backward compatibility test: services still work without IHistoryService
  • Frontend test: board selector defaults to most-recently-active non-archived board
  • Frontend test: archived boards sort after non-archived boards
  • Full backend test suite passes (all projects)
  • Full frontend test suite passes (1164 tests)
  • Frontend build + typecheck passes

CardService, ColumnService, BoardService, and LabelService now call
IHistoryService.LogActionAsync after each successful create, update,
move, delete, and archive mutation. The dependency is optional to
preserve backward compatibility.

Fixes #521
Adds IHistoryService registration so mutation services receive the
HistoryService instance via constructor injection.
12 tests covering card CRUD+move, column CRUD, board CRUD+archive,
label CRUD, and backward compatibility when IHistoryService is absent.
…board

Sort board options so non-archived boards appear first, ordered by
updatedAt descending, instead of alphabetical order that could default
to an archived board.
Adjust existing tests for updatedAt-based ordering and add test
verifying archived boards sort after non-archived boards with
most-recently-active default selection.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Self-Review Findings

What was changed

  1. Backend: Added IHistoryService (optional) dependency to CardService, ColumnService, BoardService, and LabelService. Each mutation method now calls LogActionAsync after successful persistence.
  2. DI: Registered IHistoryService interface mapping to HistoryService in ApplicationServiceRegistration.
  3. Frontend: Changed boardOptions sort in useActivityQuery from alphabetical to non-archived-first + most-recently-updated descending.

Potential issues reviewed

Performance: The audit logging calls HistoryService.LogActionAsync which does a separate SaveChangesAsync call. This means each mutation now has two SaveChangesAsync calls instead of one. For high-throughput scenarios this could be a concern. However, given Taskdeck's local-first SQLite design and typical usage patterns, this is acceptable. A future optimization could batch the audit write into the same UoW transaction.

Error handling: If LogActionAsync throws, it would propagate up and potentially cause the mutation to appear to fail even though the entity change was already persisted. This is a minor gap -- the mutation succeeded but the audit write failed. The existing HistoryService.LogActionAsync catches DomainException and returns Result.Failure, so it won't throw in normal conditions. An unhandled infrastructure exception (e.g., DB full) could still propagate. Acceptable risk for now.

Architecture: IHistoryService is defined in the Application layer and consumed by Application services -- no layer violations. The optional nullable pattern preserves backward compat and avoids breaking existing tests.

Security: No auth bypass or data leakage issues. Audit log changes field contains entity names/IDs only, no sensitive data.

Test coverage: 12 backend tests cover card CRUD+move, column CRUD, board CRUD+archive, label CRUD, plus backward compatibility. Frontend has 13 tests including the new archived-board-ordering test. Full suites pass (backend + frontend).

Missing coverage: ColumnService.ReorderColumnsAsync does not record audit (it's a bulk position update, not a typical mutation). BoardService.UpdateBoardInternalAsync with archive/unarchive via UpdateBoardDto.IsArchived records AuditAction.Updated rather than Archived/Unarchived specifically -- acceptable since the DeleteBoardAsync path correctly records Archived.

No regressions found -- all existing tests continue to pass.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request integrates IHistoryService into several application services (BoardService, CardService, ColumnService, and LabelService) to record audit logs for entity mutations such as creation, updates, movement, and deletion. It also includes a comprehensive suite of unit tests in BoardMutationAuditTests.cs to verify these audit logs are correctly triggered. On the frontend, the useActivityQuery composable was updated to sort boards by their archived status and most recent update time. The review feedback suggests enhancing the audit logs for update operations across all services by including specific details of the changed fields (e.g., name, description, color) rather than just logging that an update occurred.

Comment on lines +180 to +181
if (_historyService != null)
await _historyService.LogActionAsync("board", board.Id, AuditAction.Updated);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The audit log for an update operation is more useful if it includes details about what was changed. Currently, it only logs that an update occurred without any specifics. Consider building a string of changes from the UpdateBoardDto to pass to LogActionAsync to make the activity trail more informative.

            if (_historyService != null)
            {
                var changes = new List<string>();
                if (dto.Name is not null) changes.Add($"name='{dto.Name}'");
                if (dto.Description is not null) changes.Add("description updated");
                if (dto.IsArchived.HasValue) changes.Add($"isArchived={dto.IsArchived.Value}");
                await _historyService.LogActionAsync("board", board.Id, AuditAction.Updated, changes: string.Join("; ", changes));
            }

Comment on lines +141 to +142
if (_historyService != null)
await _historyService.LogActionAsync("card", card.Id, AuditAction.Updated, actorUserId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The audit log for an update operation would be more useful if it included details about what was changed. Currently, it only logs that an update occurred. Consider building a string of changes from the UpdateCardDto to pass to LogActionAsync.

This would make the activity trail much more informative, especially for an entity with many updatable properties like a card.

            if (_historyService != null)
            {
                var changes = new List<string>();
                if (dto.Title is not null) changes.Add($"title='{dto.Title}'");
                if (dto.Description is not null) changes.Add("description updated");
                if (dto.DueDate.HasValue) changes.Add($"dueDate={dto.DueDate.Value:O}");
                if (dto.IsBlocked.HasValue) changes.Add($"isBlocked={dto.IsBlocked.Value}");
                if (dto.BlockReason is not null) changes.Add($"blockReason='{dto.BlockReason}'");
                if (dto.LabelIds is not null) changes.Add($"labelIds=[{string.Join(",", dto.LabelIds)}]");

                await _historyService.LogActionAsync("card", card.Id, AuditAction.Updated, actorUserId, string.Join("; ", changes));
            }

Comment on lines +75 to +76
if (_historyService != null)
await _historyService.LogActionAsync("column", column.Id, AuditAction.Updated);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The audit log for an update operation is more useful if it includes details about what was changed. Currently, it only logs that an update occurred. Consider building a string of changes from the UpdateColumnDto (e.g., Name, WipLimit, Position) to pass to LogActionAsync.

            if (_historyService != null)
            {
                var changes = new List<string>();
                if (dto.Name is not null) changes.Add($"name='{dto.Name}'");
                if (dto.WipLimit.HasValue) changes.Add($"wipLimit={dto.WipLimit.Value}");
                if (dto.Position.HasValue) changes.Add($"position={dto.Position.Value}");
                await _historyService.LogActionAsync("column", column.Id, AuditAction.Updated, changes: string.Join("; ", changes));
            }

Comment on lines +66 to +67
if (_historyService != null)
await _historyService.LogActionAsync("label", label.Id, AuditAction.Updated);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The audit log for an update operation is more useful if it includes details about what was changed. Currently, it only logs that an update occurred. Consider building a string of changes from the UpdateLabelDto (Name, ColorHex) to pass to LogActionAsync.

            if (_historyService != null)
            {
                var changes = new List<string>();
                if (dto.Name is not null) changes.Add($"name='{dto.Name}'");
                if (dto.ColorHex is not null) changes.Add($"colorHex='{dto.ColorHex}'");
                await _historyService.LogActionAsync("label", label.Id, AuditAction.Updated, changes: string.Join("; ", changes));
            }

@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review -- PR #581

Critical

1. Audit logging failure can crash the mutation response (all services)

LogActionAsync is called after the primary SaveChangesAsync succeeds, but inside try blocks that only catch DomainException. The HistoryService.LogActionAsync implementation catches DomainException internally, but any non-domain infrastructure exception (e.g., DbUpdateException from SQLite disk full, OperationCanceledException, InvalidOperationException from EF Core concurrency) will propagate uncaught through the calling service.

Result: the entity mutation already committed to the database, but the HTTP response returns a 500 error. The client believes the operation failed and may retry, creating duplicate entities.

Fix: Wrap each LogActionAsync call in a try-catch that swallows/logs the exception, or make HistoryService.LogActionAsync catch Exception instead of just DomainException. Audit is secondary to the mutation -- it must never break the primary operation.

Example:

if (_historyService != null)
{
    try { await _historyService.LogActionAsync(...); }
    catch (Exception) { /* log warning, don't crash */ }
}

Or fix at the HistoryService level to catch all exceptions.

Major

2. Double SaveChangesAsync per mutation -- performance and atomicity gap

Every mutation now calls SaveChangesAsync twice: once for the entity change, once inside HistoryService.LogActionAsync. With SQLite this means two separate transactions per operation. This:

  • Doubles write latency (SQLite serializes writes via a single writer lock)
  • Creates a window where the entity is persisted but the audit log is not (inconsistent state)
  • Under concurrent load, doubles contention on the SQLite write lock

Recommendation: Either (a) add the AuditLog entity in LogActionAsync without calling SaveChangesAsync and let the caller's UoW transaction commit both atomically, or (b) document this as an accepted trade-off for v1.

3. UpdateBoardInternalAsync does not distinguish archive/unarchive from name/description update

When a board is archived or unarchived via UpdateBoardDto.IsArchived, the audit log records AuditAction.Updated with no changes string. This is misleading -- an archive operation via the update endpoint produces a different audit trail than the same operation via DeleteBoardAsync (which correctly logs AuditAction.Archived with name=...). A user unarchiving a board produces no meaningful audit entry at all.

4. ColumnService.ReorderColumnsAsync has no audit logging

This is a board mutation that changes column positions. It has a realtime notification ("reordered") but no audit log entry. This is a coverage gap -- if someone reorders columns, there's no audit trail.

5. Label assignment/removal on cards is not audited

When CreateCardAsync or UpdateCardAsync receives dto.LabelIds, labels are added/removed from the card. These label-assignment mutations are not recorded in the audit trail. The audit log only records AuditAction.Created/Updated for the card itself, with no mention of which labels changed. For compliance, knowing which labels were assigned matters.

Minor

6. UpdateBoard audit log has no changes string

BoardService.UpdateBoardInternalAsync calls LogActionAsync("board", board.Id, AuditAction.Updated) with no changes parameter, while CreateBoardInternalAsync includes name={board.Name}. This makes update audit entries useless -- you can't tell what changed (name? description? archive status?).

Same issue exists for ColumnService.UpdateColumnAsync and LabelService.UpdateLabelAsync -- both log AuditAction.Updated with no detail.

7. CardService.UpdateCardAsync does not pass changes description

The update call logs AuditAction.Updated with only actorUserId but no changes string. In contrast, CreateCardAsync includes title=.... There's no way to tell from the audit log what was actually modified on the card.

8. No actorUserId passed in most audit calls

Only BoardService.CreateBoardAsync and CardService.UpdateCardAsync pass actorUserId. All other audit calls leave userId as null. This means most audit log entries have no user attribution, making them far less useful for compliance.

For example, CardService.CreateCardAsync has no user ID. CardService.DeleteCardAsync has no user ID. ColumnService and LabelService never pass a user ID.

9. Frontend sort: localeCompare on ISO date strings works but is fragile

right.updatedAt.localeCompare(left.updatedAt) works for ISO 8601 strings because they're lexicographically sortable. However, if updatedAt is ever null, undefined, or an empty string, this will throw or produce wrong ordering. A Date.parse comparison would be more robust, or at minimum a null guard.

Nits

10. Inconsistent changes format across entity types

Card created: title={card.Title}. Board created: name={board.Name}. Column created: name={column.Name}. Card moved: target_column={dto.TargetColumnId}; position={dto.TargetPosition}. There's no schema or convention documented for the changes field. Consider a structured format (JSON) or at minimum documenting the conventions.

11. Interface doc comment says "SCAFFOLDING: Implementation pending"

IHistoryService still has /// SCAFFOLDING: Implementation pending. in its XML doc. This is now implemented -- the comment should be updated.

12. Test coverage gap: no test for audit failure resilience

There's a backward-compat test (CreateCard_WithoutHistoryService_StillSucceeds) but no test verifying behavior when LogActionAsync throws. Given finding #1, this is the most important untested path.

Overall Assessment

Needs fixes before merge. The critical issue (#1) where audit failure can crash mutations must be fixed -- this is a correctness bug that could cause data duplication in production. The double-SaveChangesAsync (#2) is a significant performance concern for SQLite. The coverage gaps (#4, #5) and missing user context (#8) reduce the audit trail's value but are acceptable to defer.

…vice level

- Add SafeLogAsync wrapper in CardService, BoardService, ColumnService, LabelService
  to catch exceptions from IHistoryService — audit failure must never crash mutations
- Add catch(Exception) in HistoryService.LogActionAsync for defense-in-depth
- Fix UpdateBoardInternalAsync to record Archived action (not Updated) when IsArchived=true
- Add audit logging to ColumnService.ReorderColumnsAsync (was missing)
- Add test: CreateCard_AuditFailure_DoesNotCrashMutation
- Add test: UpdateBoard_WithArchiveFlag_RecordsArchivedAction
- Remove stale SCAFFOLDING comment from IHistoryService
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Follow-up: Fixes pushed for Critical and Major findings

Commit 90e820b0 addresses the following from the adversarial review:

Fixed

  1. Critical Claude/create feature f 01 qr r as j4 s14advm nn qhp2 la #1 -- Audit failure crashing mutations: Added SafeLogAsync wrapper method in all four services (CardService, BoardService, ColumnService, LabelService) that catches all exceptions from IHistoryService.LogActionAsync. Also added catch (Exception) in HistoryService.LogActionAsync itself for defense-in-depth. Neither the interface contract nor the concrete implementation can now crash a mutation.

  2. Major Add comprehensive Application layer test suite #3 -- UpdateBoardInternalAsync archive/unarchive: Now correctly logs AuditAction.Archived when dto.IsArchived == true, includes "unarchived" in changes string when dto.IsArchived == false, and includes name= in all update audit entries.

  3. Major Add label validation coverage for CardService #4 -- ReorderColumnsAsync missing audit: Added audit log entry for column reorder operations.

  4. Nit Add comprehensive development history documentation #11 -- Stale SCAFFOLDING comment: Removed from IHistoryService.

New tests added

  • CreateCard_AuditFailure_DoesNotCrashMutation -- verifies mutation succeeds even when IHistoryService throws
  • UpdateBoard_WithArchiveFlag_RecordsArchivedAction -- verifies archive via update endpoint records Archived action

Test results

All 1607 backend tests pass (0 failures).

Remaining items (deferred, not blocking)

@Chris0Jeky Chris0Jeky merged commit 5e064d4 into main Mar 29, 2026
18 checks passed
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Mar 29, 2026
Chris0Jeky added a commit that referenced this pull request Mar 29, 2026
@Chris0Jeky Chris0Jeky deleted the fix/521-activity-audit-trail branch March 29, 2026 22:21
Chris0Jeky added a commit that referenced this pull request Mar 29, 2026
Update two analysis docs (chat-to-proposal gap and manual testing findings) to reflect recent fixes and testing status. Key changes: add Last Updated and status notes; mark Tier 1 improvements shipped (intent classifier regex/stemming/negation fixes, substring ordering bug, PR #579), UX parse hints shipped (PR #582), unit/integration tests shipped (PR #580), and note PR range #578#582. In manual testing findings mark OBS-2/OBS-3 resolved (PR #581) and BUG-M5 resolved (PR #578), update resolutions and remove duplicate checklist items. Minor editorial clarifications and test counts added.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

UX: Activity view audit trail not recording board mutations

1 participant